API Design Patterns — 15장 add 및 remove 사용자 메서드 딥다이브 학습 노트

출처: API Design Patterns (JJ Geewax) | 참고: https://google.aip.dev/


15장에서 다루는 내용

  • 다대다 관계의 암시적 관리 — 연관 리소스 없이 관계 관리
  • 명시적 연관 대비 암시적 연관의 장단점 — 단순함 vs 유연성
  • 사용자 메서드를 통한 리소스 연관성 생성 — AddGroupUser, RemoveGroupUser
  • 관리 리소스 선택 기준 — 어느 쪽에 add/remove를 붙일 것인가
  • 데이터 무결성 문제 대응 — 중복 추가, 미존재 제거

전체 흐름도

[14장: 연관 리소스]                        [15장: add/remove 메서드]
복잡하지만 유연                              단순하지만 제한적

┌──────────────────────┐               ┌──────────────────────┐
│ Membership 리소스     │               │ 연관 리소스 없음      │
│ - CRUD 5개 메서드     │               │ - add/remove 2개      │
│ - 별칭 2개            │               │ - list 2개            │
│ - 메타데이터 저장      │               │ - 메타데이터 없음     │
│ 총 7개 메서드         │               │ 총 4개 메서드          │
└──────────────────────┘               └──────────────────────┘

                    ↓ 어떤 것을 선택?

        ┌──────────────────────────────────────┐
        │ 관계에 메타데이터(역할, 가입일)가      │
        │ 필요한가?                             │
        │                                      │
        │ YES → 14장 연관 리소스                │
        │ NO  → 15장 add/remove (더 단순!)     │
        └──────────────────────────────────────┘

선수 지식 체크리스트

  • [ ] REST API 기본 개념 (리소스, 엔드포인트, HTTP 메서드)
  • [ ] 사용자 메서드 (9장) — add/remove는 사용자 메서드의 일종
  • [ ] 연관 리소스 (14장) — add/remove가 대안으로 제시되는 대상

핵심 키워드

키워드 의미
관리 리소스 관계를 관리하는 역할의 리소스 (add/remove 메서드가 붙는 쪽)
연관 대상 리소스 관리 리소스에 추가/제거되는 리소스
암시적 서브컬렉션 실제 서브리소스가 아니지만 서브컬렉션처럼 보이는 URL 구조
비상호관계 관리 리소스가 한쪽에만 있어 양방향 대칭이 아닌 상태

15.1 서론 — 왜 더 단순한 대안이 필요한가?

한 줄 요약

연관 리소스(14장)가 너무 복잡할 때, 약간의 제한을 감수하고 더 단순하게 다대다 관계를 관리하자!

쉬운 설명 (카카오톡 그룹채팅 비유)

카카오톡 그룹채팅에서 멤버를 관리할 때:

  • 14장 방식: "멤버십 카드"를 만들어서 역할, 가입일, 알림 설정 등을 관리 → 복잡하지만 유연
  • 15장 방식: 그냥 "초대"하고 "내보내기"만! → 단순하지만 추가 정보 없음
14장: CreateMembership({ userId: "김철수", groupId: "반모임", role: "admin" })
      → Membership 리소스 생성, 이후 Get/Update/Delete/List 5개 메서드 추가

15장: AddGroupUser({ parent: "groups/반모임", userId: "users/김철수" })
      → 끝! 추가 리소스 없음. add/remove/list만 있으면 됨

실무 예제 — 메타데이터가 불필요한 다대다 관계

1. 소셜 미디어 팔로우
   User ↔ User (팔로우한다/안 한다, 역할 없음)
   → AddUserFollower / RemoveUserFollower

2. 게시글 태그
   Post ↔ Tag (태그가 붙어있다/없다)
   → AddPostTag / RemovePostTag

3. 상품 카테고리
   Product ↔ Category (소속 여부만)
   → AddCategoryProduct / RemoveCategoryProduct

4. 재생목록 영상
   Playlist ↔ Video (포함 여부만)
   → AddPlaylistVideo / RemovePlaylistVideo

5. 사용자 역할 (역할이 단순 문자열이 아닌 별도 리소스일 때)
   User ↔ Role (가지고 있다/없다)
   → AddUserRole / RemoveUserRole

핵심 체크포인트

  • ✅ 연관 리소스(14장)의 더 단순한 대안
  • ✅ 관계 메타데이터가 불필요할 때 적합
  • ✅ 클라이언트가 연관 리소스를 직접 다룰 필요 없음
  • ✅ 메서드 4개로 다대다 관계 관리 가능

15.2 개괄 — add/remove 패턴의 원리

한 줄 요약

관계를 구성하는 리소스를 숨기고, add/remove 사용자 메서드로 연관성을 생성/삭제한다.

2가지 제약 조건

제약 설명 비유
메타데이터 없음 관계의 존재 여부만 저장. 역할, 가입일 등 추가 정보 불가 전화번호부에 이름만 있고 관계(친구/동료) 없음
관리 리소스 선택 한쪽 리소스만 관리자 역할. 양쪽 모두 불가 카톡 그룹방 방장만 초대/강퇴 가능

관리 리소스 선택

방식 1: 그룹이 관리 리소스 (그룹이 사용자를 관리)
─────────────────────────────────────────────────
POST /groups/1/users:add     { userId: "users/alice" }   ← "그룹에 사용자를 추가"
POST /groups/1/users:remove  { userId: "users/alice" }   ← "그룹에서 사용자를 제거"
GET  /groups/1/users                                      ← "그룹의 사용자 목록"
GET  /users/alice/groups                                  ← "사용자의 그룹 목록"

방식 2: 사용자가 관리 리소스 (사용자가 그룹을 관리)
─────────────────────────────────────────────────
POST /users/alice/groups:add     { groupId: "groups/1" }  ← "사용자에 그룹을 추가"
POST /users/alice/groups:remove  { groupId: "groups/1" }  ← "사용자에서 그룹을 제거"
GET  /users/alice/groups                                   ← "사용자의 그룹 목록"
GET  /groups/1/users                                       ← "그룹의 사용자 목록"

관리 리소스 선택 기준

질문: "누가 관계를 관리하는 것이 자연스러운가?"

┌───────────────────────────────────────────────────────────┐
│ 관계                    │ 관리 리소스  │ 이유               │
├───────────────────────────────────────────────────────────┤
│ 그룹 ↔ 사용자           │ Group       │ "그룹에 사람을 초대" │
│ 재생목록 ↔ 동영상       │ Playlist    │ "재생목록에 영상 추가"│
│ 게시글 ↔ 태그           │ Post        │ "게시글에 태그 달기"  │
│ 사용자 ↔ 팔로워         │ User (대상) │ "이 사용자를 팔로우"  │
│ 상품 ↔ 카테고리         │ Category    │ "카테고리에 상품 배치"│
│ 쇼핑카트 ↔ 상품         │ Cart        │ "장바구니에 상품 담기"│
└───────────────────────────────────────────────────────────┘

핵심: "A에 B를 추가/제거한다"가 자연스러운 쪽이 관리 리소스

핵심 체크포인트

  • ✅ 어느 쪽을 관리 리소스로 선택하든 하나만 선택
  • ✅ 선택 기준: "A에 B를 추가"가 자연스러운 쪽이 A (관리 리소스)
  • ✅ 양쪽 모두 add/remove를 제공하면 안 됨 (중복, 혼란)

15.3 구현

add 및 remove 메서드

한 줄 요약

9장의 사용자 메서드 패턴(콜론+동작명)을 따라 add/remove를 구현한다.

상세 API 호출 예시

표 15.1 Add 및 remove 메서드 요약

┌──────────────────┬───────────────────────────┬─────────────────────────────┐
│ 액션              │ 메서드                     │ HTTP                        │
├──────────────────┼───────────────────────────┼─────────────────────────────┤
│ Alice를           │ AddGroupUser({            │ POST /groups/1/users:add    │
│ 그룹 1에 추가     │   parent: "groups/1",     │ Body: { userId: "users/1" } │
│                  │   userId: "users/1"       │                             │
│                  │ });                       │ → 200 OK (반환값 없음)       │
├──────────────────┼───────────────────────────┼─────────────────────────────┤
│ Alice를           │ RemoveGroupUser({         │ POST /groups/1/users:remove │
│ 그룹 1에서 제거   │   parent: "groups/1",     │ Body: { userId: "users/1" } │
│                  │   userId: "users/1"       │                             │
│                  │ });                       │ → 200 OK (반환값 없음)       │
└──────────────────┴───────────────────────────┴─────────────────────────────┘

네이밍 규칙: Add/Remove + 관리리소스(단수) + 대상리소스(단수) - AddGroupUser (그룹에 사용자 추가) - RemoveGroupUser (그룹에서 사용자 제거) - AddPlaylistVideo (재생목록에 동영상 추가) - RemovePostTag (게시글에서 태그 제거)

요청에 식별자만 포함하는 이유

// ❌ 잘못된 설계: 전체 리소스를 본문에 포함
POST /groups/1/users:add
{
    user: {
        id: "users/alice",
        name: "Alice",          // ← 불필요한 데이터
        email: "alice@test.com" // ← 이걸 보내면 User도 업데이트하는 건가? 혼란!
    }
}

// ✅ 올바른 설계: 식별자만 포함
POST /groups/1/users:add
{
    userId: "users/alice"       // ← 추가할 대상의 식별자만!
}

핵심: 나머지 정보가 본디 목적(관계 수립)과 무관하며, 클라이언트가 동시에 리소스 업데이트도 가능하다고 오판할 수 있기 때문.

15.3.1 연관 리소스 나열

한 줄 요약

양방향 list 메서드로 "그룹의 사용자들"과 "사용자의 그룹들"을 모두 조회 가능.

표 15.2 list 메서드 요약

┌──────────────────────┬────────────────────────────┬─────────────────────┐
│ 질문                  │ 메서드                      │ HTTP                │
├──────────────────────┼────────────────────────────┼─────────────────────┤
│ 그룹 1에 속한         │ ListGroupUsers({           │ GET /groups/1/users │
│ 사용자는?             │   parent: "groups/1"       │                     │
│                      │ })                         │                     │
│                      │ → User[] 반환              │                     │
├──────────────────────┼────────────────────────────┼─────────────────────┤
│ 사용자 1이 속한       │ ListUserGroups({           │ GET /users/1/groups │
│ 그룹은?               │   parent: "users/1"        │                     │
│                      │ })                         │                     │
│                      │ → Group[] 반환             │                     │
└──────────────────────┴────────────────────────────┴─────────────────────┘

비유: 암시적 서브컬렉션

URL: GET /groups/1/users

이 URL만 보면 "users"가 Group의 서브리소스 같지만,
실제로는 서브리소스가 아니다!
→ Group 리소스 아래에 User 리소스가 중첩된 것이 아님
→ "그룹 1과 연관된 사용자 목록"을 보여주는 편의 엔드포인트

이것을 "암시적 서브컬렉션"이라고 한다.
실제 서브리소스: /groups/1/settings (Group이 소유)
암시적 서브컬렉션: /groups/1/users (연관된 User 목록)

페이징과 필터링

// 그룹에 사용자가 수천 명일 수 있으므로 페이징 지원
GET /groups/1/users?maxPageSize=50
→ {
    users: [{ id: "users/alice", ... }, ...],   // 최대 50명
    nextPageToken: "eyJ0..."                     // 다음 페이지 토큰
}

// 다음 페이지
GET /groups/1/users?maxPageSize=50&pageToken=eyJ0...
→ {
    users: [...],
    nextPageToken: null                          // 마지막 페이지
}

15.3.2 데이터 무결성

한 줄 요약

중복 추가는 409 Conflict, 미존재 제거는 412 Precondition Failed.

시나리오별 동작

시나리오 1: 정상 추가
─────────────────────────────────────────────
POST /groups/1/users:add { userId: "users/jimmy" }
→ 200 OK ✅ (Jimmy가 그룹 1에 추가됨)

시나리오 2: 중복 추가
─────────────────────────────────────────────
POST /groups/1/users:add { userId: "users/jimmy" }
→ 200 OK ✅ (처음이니까 성공)

POST /groups/1/users:add { userId: "users/jimmy" }
→ 409 Conflict ❌ (이미 멤버입니다!)

→ 멱등성 활용: 성공(200)이든 충돌(409)이든,
  결과적으로 Jimmy는 그룹 1의 멤버. 목적 달성!

시나리오 3: 정상 제거
─────────────────────────────────────────────
POST /groups/1/users:remove { userId: "users/jimmy" }
→ 200 OK ✅ (Jimmy가 그룹 1에서 제거됨)

시나리오 4: 미존재 제거
─────────────────────────────────────────────
POST /groups/1/users:remove { userId: "users/unknown" }
→ 412 Precondition Failed ❌
  ("이 사용자는 이 그룹의 멤버가 아닙니다")

시나리오 5: 존재하지 않는 사용자 추가
─────────────────────────────────────────────
POST /groups/1/users:add { userId: "users/nonexistent" }
→ 404 Not Found ❌ ("해당 사용자가 존재하지 않습니다")

14장의 고유성 제약과 비교

14장 (연관 리소스):
CreateMembership({ userId: "jimmy", groupId: "1" })  → 201 Created
CreateMembership({ userId: "jimmy", groupId: "1" })  → 409 Conflict
→ Membership 리소스가 명시적이므로 중복 검증도 명확

15장 (add/remove):
AddGroupUser({ parent: "groups/1", userId: "users/jimmy" })  → 200 OK
AddGroupUser({ parent: "groups/1", userId: "users/jimmy" })  → 409 Conflict
→ 내부적으로 관계를 저장하지만 리소스로 노출하지 않음
→ 중복 검증 로직은 동일하지만 클라이언트에게는 더 단순

15.3.3 최종 API 정의

abstract class GroupApi {
    static version = "v1";
    static title = "Group API (add/remove 패턴)";

    // === User 표준 메서드 ===
    @post("/users")
    CreateUser(req: CreateUserRequest): User;

    @get("/{id=users/*}")
    GetUser(req: GetUserRequest): User;

    @get("/users")
    ListUsers(req: ListUsersRequest): ListUsersResponse;

    @patch("/{resource.id=users/*}")
    UpdateUser(req: UpdateUserRequest): User;

    @delete("/{id=users/*}")
    DeleteUser(req: DeleteUserRequest): void;

    // === Group 표준 메서드 ===
    @post("/groups")
    CreateGroup(req: CreateGroupRequest): Group;

    @get("/{id=groups/*}")
    GetGroup(req: GetGroupRequest): Group;

    @get("/groups")
    ListGroups(req: ListGroupsRequest): ListGroupsResponse;

    @patch("/{resource.id=groups/*}")
    UpdateGroup(req: UpdateGroupRequest): Group;

    @delete("/{id=groups/*}")
    DeleteGroup(req: DeleteGroupRequest): void;

    // === add/remove 사용자 메서드 ===
    @post("{parent=groups/*}/users:add")
    AddGroupUser(req: AddGroupUserRequest): void;

    @post("{parent=groups/*}/users:remove")
    RemoveGroupUser(req: RemoveGroupUserRequest): void;

    // === 양방향 list ===
    @get("{parent=groups/*}/users")
    ListGroupUsers(req: ListGroupUsersRequest): ListGroupUsersResponse;

    @get("{parent=users/*}/groups")
    ListUserGroups(req: ListUserGroupsRequest): ListUserGroupsResponse;
}

// === 리소스 인터페이스 ===

interface Group {
    id: string;             // "groups/engineering"
    name: string;
    description: string;
    userCount: number;      // 사용자 수 (인라인 목록 아님!)
}

interface User {
    id: string;             // "users/alice"
    displayName: string;
    emailAddress: string;
}

// === 요청 인터페이스 ===

interface AddGroupUserRequest {
    parent: string;         // 관리 리소스 식별자: "groups/1"
    userId: string;         // 추가할 사용자: "users/alice"
}

interface RemoveGroupUserRequest {
    parent: string;         // 관리 리소스 식별자: "groups/1"
    userId: string;         // 제거할 사용자: "users/alice"
}

// === 응답 인터페이스 ===

interface ListGroupUsersResponse {
    users: User[];          // 사용자 목록 (Membership이 아닌 User!)
    nextPageToken: string;
}

interface ListUserGroupsResponse {
    groups: Group[];        // 그룹 목록 (Membership이 아닌 Group!)
    nextPageToken: string;
}

API 메서드 카운트

User 관련: 5개 (Create, Get, List, Update, Delete)
Group 관련: 5개 (Create, Get, List, Update, Delete)
관계 관련: 4개 (Add, Remove, ListGroupUsers, ListUserGroups)
────────────────────────────────────────────
총 14개 메서드 (14장의 17개보다 3개 적음!)

핵심 체크포인트

  • ✅ add/remove는 사용자 메서드 (POST + 콜론 구분자)
  • ✅ 요청에 식별자만 포함 (전체 리소스 아님)
  • ✅ list 메서드는 User[]/Group[] 반환 (Membership이 아님)
  • ✅ 중복 추가: 409 Conflict, 미존재 제거: 412 Precondition Failed
  • ✅ 양방향 list 지원 (ListGroupUsers + ListUserGroups)

15.4 트레이드오프

15.4.1 비상호관계 (Nonreciprocity)

관리 리소스를 하나만 선택해야 하므로 비대칭적이다.

Group이 관리 리소스일 때:
✅ POST /groups/1/users:add    — "그룹에 사용자를 추가" (자연스러움)
❌ POST /users/alice/groups:add — 이 메서드는 존재하지 않음!

→ 클라이언트가 "Alice를 engineering 그룹에 넣고 싶다"면
  POST /groups/engineering/users:add를 호출해야 함
  POST /users/alice/groups:add는 불가!

비유: 카톡 그룹방에서 방장만 초대할 수 있는 것과 같다.
     일반 멤버가 "나를 이 그룹에 추가해줘"라고 할 수 없음.

15.4.2 관계 메타데이터 불가

14장 (연관 리소스): 관계에 추가 정보 저장 가능
{
    userId: "alice",
    groupId: "engineering",
    role: "admin",              ← 역할
    joinedAt: "2024-03-15",     ← 가입일
    expireTime: "2025-03-15"    ← 만료일
}

15장 (add/remove): 관계의 존재 여부만
"Alice는 engineering의 멤버이다" (O)
"Alice는 engineering의 admin이다" (X) ← 표현 불가!
"Alice가 2024-03-15에 가입했다" (X) ← 표현 불가!

해결이 필요하면? → 14장의 연관 리소스로 전환하거나, 관계와 별도로 메타데이터를 저장하는 다른 방법을 고안해야 한다.

15.4.3 14장 vs 15장 선택 의사결정 트리

Q1: 관계에 메타데이터(역할, 가입일 등)가 필요한가?
├── YES → 14장 연관 리소스 (Membership 패턴)
│
└── NO → Q2: API 복잡도를 최소화하고 싶은가?
         ├── YES → 15장 add/remove
         └── NO → 14장 연관 리소스 (메타데이터 없이도 사용 가능)

Q3: 나중에 메타데이터가 필요해질 가능성이 높은가?
├── YES → 처음부터 14장으로 시작 (마이그레이션 비용 방지)
└── NO → 15장으로 시작, 필요 시 14장으로 전환

15.5 연습문제 풀이

Q1: Recipe와 Ingredient의 다대다 관계에서 관리 리소스는?

A: Recipe가 관리 리소스이다. "레시피에 재료를 추가한다"가 자연스럽다. - AddRecipeIngredient, RemoveRecipeIngredient - ListRecipeIngredients (이 레시피에 필요한 재료) - ListIngredientRecipes (이 재료를 사용하는 레시피)

Q2: 팔로우 관계에서 관리 리소스는?

A: 팔로우를 받는 사용자가 관리 리소스이다. "이 사용자에 팔로워를 추가/제거"가 시스템 관점에서 자연스럽다. 또는 팔로우를 하는 사용자가 관리 리소스: AddUserFollowing, RemoveUserFollowing. 도메인에 따라 다르므로 팀과 합의 필요.

Q3: "Alice가 engineering 그룹에서 admin 역할을 갖는다"를 15장 패턴으로 표현할 수 있는가?

A: 표현할 수 없다. 15장 패턴은 관계의 존재 여부만 저장하므로 역할 정보를 포함할 방법이 없다. 이 경우 14장의 연관 리소스(Membership 리소스에 role 필드)를 사용해야 한다.

Q4: add 메서드 호출 시 409 Conflict를 받으면 클라이언트는 어떻게 처리해야 하는가?

A: 목적이 "관계를 만드는 것"이면 409도 성공으로 취급할 수 있다. 이미 관계가 존재한다는 뜻이므로 목적은 달성된 것이다. 멱등적 처리 패턴.


부록 A: 용어 사전

용어 영문 의미
관리 리소스 Managing Resource 관계를 관리하는 역할의 리소스 (add/remove가 붙는 쪽)
연관 대상 리소스 Associated Resource 관리 리소스에 추가/제거되는 리소스
암시적 서브컬렉션 Implicit Sub-collection 실제 서브리소스 없이 URL만 서브컬렉션처럼 보이는 구조
비상호관계 Nonreciprocity 관리 리소스가 한쪽에만 있어 양방향 대칭이 아닌 상태
멱등성 Idempotency 같은 요청을 여러 번 보내도 결과가 동일한 성질

부록 B: 핵심 비교표 — 14장 vs 15장

항목 14장 연관 리소스 15장 add/remove
관계 리소스 Membership (별도 리소스) 없음 (숨김)
관계 관련 메서드 수 최대 7개 4개
메타데이터 ✅ 저장 가능 ❌ 불가
복잡도 높음 낮음
양방향 쿼리 ✅ 별칭 메서드 ✅ list 메서드
관계 대칭성 ✅ 대칭 ❌ 비대칭 (관리 리소스 하나)
list 반환 타입 Membership[] User[]/Group[] (리소스 직접)
중복 생성 409 Conflict 409 Conflict
적합한 경우 메타데이터 필요 시 존재 여부만 필요 시

부록 C: 추천 참고 자료 & 링크

자료 설명
AIP-142 (google.aip.dev) Google의 시간 기반 변경 컬렉션 패턴
9장 사용자 메서드 add/remove의 기반이 되는 사용자 메서드 개념
14장 연관 리소스 유연한 다대다 관계 (add/remove의 상위 호환)
13장 교차 참조 일대다 관계의 기본 참조 패턴
난이도
에피소드
질문
카드를 로딩 중...
답변

클릭하거나 Space를 눌러 뒤집기

0 / 0
학습 진도 0%
이동   Space 뒤집기   R 셔플